/*
 * Copyright 2004-2005 The Apache Software Foundation or its licensors,
 *                     as applicable.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.jackrabbit.extension.configuration;

import java.util.ArrayList;
import java.util.Calendar;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;

import javax.jcr.NodeIterator;
import javax.jcr.PathNotFoundException;
import javax.jcr.Property;
import javax.jcr.PropertyIterator;
import javax.jcr.PropertyType;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import javax.jcr.Value;
import javax.jcr.ValueFactory;

import org.apache.commons.configuration.ConfigurationException;
import org.apache.commons.configuration.ConfigurationKey;
import org.apache.commons.configuration.HierarchicalConfiguration;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.jackrabbit.extension.ExtensionDescriptor;

/**
 * The <code>ItemConfiguration</code> extends the
 * <code>HierarchicalConfiguration</code> class providing support to load the
 * configuration from a repository. It represents the repository subtree from
 * which the configuration is loaded as a configuration tree of configuration
 * nodes and attributes.
 * <p>
 * The configuration is rooted at a user supplied repository node which must be
 * defined such, that properties and child nodes of any type and name may be
 * added. The best way to achieve this is to define the node as of type
 * <code>nt:unstructured</code>.
 * <p>
 * <b>Note on names</b>
 * <p>
 * This implementation uses the repository item names as (basis of) the names of
 * the hierarchy configuration nodes. As such there exists a restriction on
 * those names: The <code>HierarchicalConfiguration</code> extended by this
 * class uses dots (<code>.</code>) as hierarchy level separators. Therefore
 * any configuration node's name with a dot in it will likely lead to unsuable
 * configuration.
 * <p>
 * <i>Therefore it is strongly recommended to not use dots in repository element
 * names to be used by this configuration class.</i>
 * <p id="dataTypeConversion">
 * <b>Data Type Conversion</b>
 * <p>
 * This implementation tries its best to preserve the configuration data type
 * when loading or saving the configuration data. Because the mapping between
 * Java data types supported by the configuration objects and the data types
 * supported by the repository, a mapping has to be applied, which may lead to a
 * certain but acceptable loss of accuracy.
 * <p>
 * When loading values from the repository, the following type conversion
 * applies: <table>
 * <tr>
 * <th>JCR Type
 * <th>Java Type</tr>
 * <tr>
 * <td>Boolean
 * <td>Boolean</tr>
 * <tr>
 * <td>Date
 * <td>Calendar</tr>
 * <tr>
 * <td>Double
 * <td>Double</tr>
 * <tr>
 * <td>Long
 * <td>Long</tr>
 * <tr>
 * <td>Binary, Name, Path, Reference, String, Undefined
 * <td>String</tr>
 * </table>
 * <p>
 * When saveing configuaration data to the repository, the following type
 * conversion applies: <table>
 * <tr>
 * <th>Java Type
 * <th>JCR Type</tr>
 * <tr>
 * <td>String
 * <td>String</tr>
 * <tr>
 * <td>Boolean
 * <td>Boolean</tr>
 * <tr>
 * <td>Calendar
 * <td>Date</tr>
 * <tr>
 * <td>Double or Float
 * <td>Double</tr>
 * <tr>
 * <td>Number except Double and Float
 * <td>Long</tr>
 * <tr>
 * <td>Other types, incl. <code>null</code>
 * <td>String</tr>
 * </table>
 *
 * @author Felix Meschberger
 * @version $Rev:$, $Date$
 */
public class ItemConfiguration extends HierarchicalConfiguration implements
        RepositoryConfiguration {

    /** default log */
    private static final Log log = LogFactory.getLog(ExtensionDescriptor.class);

    /**
     * The name of the property providing the configuration value of a
     * configuration node.
     */
    private static final String NODE_CONTENT_PROPERTY = "__DEFAULT__";

    /**
     * The <code>Node</code> to which this configuration is attached. The
     * configuration data itself is loaded and saved from/to the
     * <code>configuration</code> child node of this node.
     *
     * @see #load(javax.jcr.Node)
     * @see #save(javax.jcr.Node)
     */
    private javax.jcr.Node jcrNode;

    /**
     * The backlog of absolute paths of items which backed removed configuration
     * data. This set is worked through to remove the items when the
     * configuration is saved.
     *
     * @see #save(javax.jcr.Node)
     * @see ItemNode#removeReference()
     */
    private Set deleteBackLog;

    /**
     * Creates an empty configuration not hooked to any node.
     */
    public ItemConfiguration() {
        super();
    }

    /**
     * Creates a configuration attached to the given <code>node</code> and
     * load the configuration data from the <code>configuration</code> child
     * node.
     * <p>
     * If <code>node</code> is <code>null</code>, this constructor has the same
     * effect as the default constructor ({@link #ItemConfiguration()} in that
     * this configuration is not attached to a <code>Node</code> and
     * configuration is not loaded.
     *
     * @param node The <code>Node</code> containing the configuration data.
     *
     * @throws ConfigurationException If an error occurrs loading the
     *      configuration data.
     */
    public ItemConfiguration(javax.jcr.Node node) throws ConfigurationException {
        super();

        setNode(node);
        load();
    }

    /**
     * Returns the <code>Node</code> to which this configuration is attached.
     * If this configuration is not attached to a node, this method returns
     * <code>null</code>.
     */
    public javax.jcr.Node getNode() {
        return jcrNode;
    }

    /**
     * Attaches this configuration to the given node to provide
     * ({@link #load(javax.jcr.Node)}) or take ({@link #save(javax.jcr.Node)})
     * configuration data. To detach this configuration from the repository,
     * set <code>node</code> to <code>null</code>.
     *
     * @param node The <code>Node</code> to which this configuration is
     *            attached or <code>null</code> to detach the configuration.
     */
    public void setNode(javax.jcr.Node node) {
        // if the new node is different from the old node, remove the current
        // configuration's references
        if (isDifferent(node)) {
            removeReferences(getRoot());
        }

        // set the new node
        this.jcrNode = node;
    }

    /**
     * Creates an instance of the <code>ItemNode</code> class with an empty
     * reference.
     * <p>
     * As noted in the class comment, the name should not contain a dot,
     * otherwise the <code>HierarchicalConfiguration</code> class will have
     * problems resolving the configuration.
     *
     * @param name The name of the new configuratio node.
     */
    protected Node createNode(String name) {
        return new ItemNode(name, null);
    }

    /**
     * Loads the configuration data from the <code>Node</code> to which this
     * configuration is attached. If this configuration is not attached to
     * a <code>Node</code>, this method has no effect.
     * <p>
     * If configuration data is already present in this configuration, the data
     * is extended by the data loaded from the <code>Node</code>. To prevent
     * such additions, clear this configuration before loading new data.
     *
     * @throws ConfigurationException If an error occurrs loading the
     *      configuration data.
     *
     * @see #load(javax.jcr.Node)
     */
    public void load() throws ConfigurationException {
        if (jcrNode != null) {
            load(jcrNode);
        }
    }

    /**
     * Loads the configuration data from the given <code>node</code>. If
     * <code>node</code> is <code>null</code>, a <code>NullPointerException</code>
     * is thrown.
     * <p>
     * If configuration data is already present in this configuration, the data
     * is extended by the data loaded from the <code>Node</code>. To prevent
     * such additions, clear this configuration before loading new data.
     *
     * @param node The <code>Node</code> containing the configuration to be
     *      loaded into this configuration. This must no be <code>null</code>.
     *
     * @throws NullPointerException if <code>node</code> is <code>null</code>.
     * @throws ConfigurationException If an error occurrs loading the
     *      configuration data.
     */
    public void load(javax.jcr.Node node) throws ConfigurationException {
        try {
            boolean sameNode = !isDifferent(node);

            // construct the hierarchy and record references if loading
            // from the node this configuration is attached to
            constructHierarchy(getRoot(), node, sameNode);
        } catch (RepositoryException re) {
            throw new ConfigurationException(re);
        }
    }

    /**
     * Saves the configuration data to the <code>Node</code> to which this
     * configuration is attached. If this configuration is not attached to
     * a <code>Node</code>, this method has no effect.
     *
     * @throws ConfigurationException If an error occurrs saving the
     *      configuration data.
     *
     * @see #save(javax.jcr.Node)
     */
    public void save() throws ConfigurationException {
        if (jcrNode != null) {
            save(jcrNode);
        }
    }

    /**
     * Saves the configuration data to the given <code>node</code>. If
     * <code>node</code> is <code>null</code>, a <code>NullPointerException</code>
     * is thrown.
     *
     * @param node The <code>Node</code> to store the configuration to. This
     *      must no be <code>null</code>.
     *
     * @throws NullPointerException if <code>node</code> is <code>null</code>.
     * @throws ConfigurationException If an error occurrs saving the
     *      configuration data.
     */
    public void save(javax.jcr.Node node) throws ConfigurationException {
        boolean lockable = false;
        try {
            // remove the node references from the current configuration
            // nodes if the destination is different from the node to which
            // the configuration is attached
            if (isDifferent(node)) {
                removeReferences(getRoot());
            }

            lockable = node.isNodeType("mix:lockable");
            if (lockable) {
                if (node.isLocked()) {
                    // trick: reset lockable to not unlock in finally{}
                    lockable = false;
                    throw new ConfigurationException("Configuration node is locked");
                }

                // deep session lock
                node.lock(true, true);
            }

            // check whether the node is versionable
            boolean versionable = node.isNodeType("mix:versionable");

            // make sure the node is checked out for modification
            if (versionable && !node.isCheckedOut()) {
                node.checkout();
            }

            // remove all items which have to be removed because the
            // configuration which were backed by them has been removed
            if (deleteBackLog != null) {
                Session session = node.getSession();
                for (Iterator di=deleteBackLog.iterator(); di.hasNext(); ) {
                    String itemPath = (String) di.next();
                    try {
                        session.getItem(itemPath).remove();
                    } catch (PathNotFoundException pnfe) {
                        // might have already been removed, ignore
                        log.debug("Item " + itemPath + " cannot be accessed for removal",
                            pnfe);
                    }
                }
            }

            // store now
            ItemBuilderVisitor builder = new ItemBuilderVisitor(node);
            builder.processDocument(getRoot());

            // save modifications
            node.save();

            // checkin after saving
            if (versionable) {
                node.checkin();
            }

        } catch (RepositoryException re) {
            throw new ConfigurationException("Cannot save configuration", re);
        } finally {
            // if the node is still modified, this is an error and we
            // rollback
            try {
                if (node.isModified()) {
                    node.refresh(false);
                } else {
                    // reset deleteBackLog, because all items have been removed
                    // and need not be removed the next time.
                    // (If an error occurred saving the configuration, the back
                    // log must remain, such that the deleted items may be
                    // removed the next time, save() is called).
                    deleteBackLog = null;
                }
            } catch (RepositoryException re) {
                log.error("Problem refreshing persistent config state", re);
            }

            // unlock the node again
            try {
                if (lockable && node.isLocked()) {
                    node.unlock();
                }
            } catch (RepositoryException re) {
                log.warn("Cannot unlock configuration node", re);
            }
        }
    }

    /**
     * Returns <code>true</code> if <code>newNode</code> is not the same
     * repository <code>Node</code> as the <code>Node</code> to which this
     * configuration is currently associated.
     * <p>
     * Removing the references makes sure that the complete configuration data
     * is written to the repository the next time {@link #save()} is called.
     *
     * @param newNode The repository <code>Node</code> to which the current
     *      base <code>Node</code> is compared.
     *
     * @return <code>true</code> if <code>newNode</code> is different to the
     *      <code>Node</code> to which the configuration is currently attached.
     */
    private boolean isDifferent(javax.jcr.Node newNode) {
        // return false if the objects are the same
        if (jcrNode == newNode) {
            return false;
        }

        // return true if no node yet and new is not null
        if (jcrNode == null) {
            return newNode != null;
        }

        // return true if the new node is null and the old is set
        if (newNode == null) {
            return jcrNode != null;
        }

        // otherwise try to compare the new to the old node
        try {
            return !jcrNode.isSame(newNode);
        } catch (RepositoryException re) {
            // cannot check whether the nodes are different, assume yes
            log.warn("Cannot check whether the current and new nodes " +
                "are different, assuming they are", re);
        }

        // fallback to different in case of problems
        return true;
    }

    /**
     * Vists all configuration nodes starting from the given <code>node</code>
     * and resets all node's reference fields to <code>null</code>. This forces
     * complete configuration storage on the next call to the {@link #save()} or
     * {@link #save(javax.jcr.Node)} methods.
     *
     * @param node The <code>Node</code> at which to start removing references
     */
    private static void removeReferences(Node node) {
        // remove repository item references from the nodes
        node.visit(new NodeVisitor() {
            public void visitBeforeChildren(Node node, ConfigurationKey key) {
                node.setReference(null);
            };
        }, null);
    }

    /**
     * Creates the internal configuration hierarchy of {@link ItemNode}s from
     * the items in the repository.
     *
     * @param node The configuration node to which the new configuration is
     *      attached.
     * @param element The JCR <code>Node</code> from which the configuration
     *      is read.
     * @param elemRefs <code>true</code> if the configuration nodes created
     *      while reading the repository items get the reference fields set to
     *      the corresponding repository item.
     *
     * @throws RepositoryException If an error occurrs reading from the
     *      repository.
     */
    private void constructHierarchy(Node node, javax.jcr.Node element,
            boolean elemRefs) throws RepositoryException {

        // create attribute child nodes for the element's properties
        processAttributes(node, element, elemRefs);

        // read the element's child nodes as child nodes into the configuration
        NodeIterator list = element.getNodes();
        while (list.hasNext()) {
            javax.jcr.Node jcrNode = list.nextNode();

            // ignore protected nodes
            if (jcrNode.getDefinition().isProtected()) {
                continue;
            }

            Node childNode = new ItemNode(jcrNode.getName(),
                elemRefs ? jcrNode.getPath() : null);
            constructHierarchy(childNode, jcrNode, elemRefs);
            node.addChild(childNode);
        }
    }

    /**
     * Helper method for constructing node objects for the attributes of the
     * given XML element.
     *
     * @param node the actual node
     * @param element the actual XML element
     * @param elemRefs a flag whether references to the XML elements should be
     *            set
     * @param node The configuration node to which the new configuration is
     *      attached.
     * @param element The JCR <code>Node</code> whose properties are to be
     *      read and attached.
     * @param elemRefs <code>true</code> if the configuration nodes created
     *      while reading the properties get the reference fields set to the
     *      corresponding property.
     *
     * @throws RepositoryException If an error occurrs reading from the
     *      repository.
     */
    private void processAttributes(Node node, javax.jcr.Node element,
            boolean elemRefs) throws RepositoryException {

        PropertyIterator attributes = element.getProperties();
        while (attributes.hasNext()) {
            Property prop = attributes.nextProperty();

            // ignore protected properties
            if (prop.getDefinition().isProtected()) {
                continue;
            }

            Value[] values;
            if (prop.getDefinition().isMultiple()) {
                values = prop.getValues();
            } else {
                values = new Value[] { prop.getValue() };
            }

            if (NODE_CONTENT_PROPERTY.equals(prop.getName())) {
                // this is the value of the node itself
                // only consider the first value
                if (values.length > 0) {
                    node.setValue(importValue(values[0]));
                }
            } else {
                String name = ConfigurationKey.constructAttributeKey(prop.getName());
                String ref = elemRefs ? prop.getPath() : null;
                for (int i = 0; i < values.length; i++) {
                    Node child = new ItemNode(name, ref);
                    child.setValue(importValue(values[i]));
                    node.addChild(child);
                }
            }
        }
    }

    /**
     * The <code>ItemNode</code> class extends the standard <code>Node</code>
     * class by support for removing underlying repository items in case of
     * removal of a configuration node.
     *
     * @author Felix Meschberger
     * @version $Rev:$, $Date$
     */
    private class ItemNode extends Node {

        /*
         * This class is not static to have a reference to the owning instance
         * such that the deleteBackLog set may be accessed which is used to
         * record items to be removed due to ItemNode removals
         */

        /** fake serialVersionUID */
        private static final long serialVersionUID = 1L;

        /**
         * Creates an instance of this node type presetting the reference.
         *
         * @param name The name of the new configuration node.
         * @param reference The (optional) reference to initially set on the
         *      new configuration node. This may be <code>null</code>.
         */
        protected ItemNode(String name, String reference) {
            super(name);
            setReference(reference);
        }

        /**
         * Removes the associated repository item if this node is removed
         * from the configuration.
         */
        protected void removeReference() {
            if (getReference() != null) {

                if (ConfigurationKey.isAttributeKey(getName())) {
                    List list = getParent().getChildren(getName());
                    if (list != null && list.size() > 0) {
                        for (Iterator ci=list.iterator(); ci.hasNext(); ) {
                            // clear references of sibblings
                            ((Node) ci.next()).setReference(null);
                        }
                    }
                }

                if (deleteBackLog == null) {
                    deleteBackLog = new HashSet();
                }
                deleteBackLog.add(getReference());
            }
        }
    }

    /**
     * The <code>ItemBuilderVisitor</code> class stores the configuration
     * rooted at a given <code>Node</code> to the repository <code>Node</code>
     * defined at construction time.
     * <p>
     * This visitor just adds nodes and properties to the repository and does
     * not care whether the operations actually overwrite data or not. It is
     * recommended that the JCR <code>Node</code> from which the visitor is
     * created be cleared before processing the configuration through the
     * {@link #processDocument(Node)} method.
     *
     * @author Felix Meschberger
     * @version $Rev:$, $Date$
     */
    private static class ItemBuilderVisitor extends BuilderVisitor {

        /** Stores the document to be constructed. */
        private javax.jcr.Node jcrNode;

        /**
         * Creates a new instance of <code>ItemBuilderVisitor</code> storing the
         * configuration at and below the given <code>jcrNode</code>.
         *
         * @param jcrNode The JCR <code>Node</code> to take the configuration.
         */
        public ItemBuilderVisitor(javax.jcr.Node jcrNode) {
            this.jcrNode = jcrNode;
        }

        /**
         * Processes the node hierarchy and adds new items to the repository
         *
         * @param rootNode The configuration <code>Node</code> to start at in
         *      the configuration hierarchy.
         */
        public void processDocument(Node rootNode) throws RepositoryException {
            rootNode.setReference(jcrNode.getPath());
            rootNode.visit(this, null);
        }

        /**
         * Inserts a new node. This implementation ensures that the correct XML
         * element is created and inserted between the given siblings.
         *
         * @param newNode the node to insert
         * @param parent the parent node
         * @param sibling1 the first sibling
         * @param sibling2 the second sibling
         * @return the new node
         */
        protected Object insert(Node newNode, Node parent, Node sibling1,
            Node sibling2) {

            try {
                // get the parent's owning node
                javax.jcr.Node parentNode;
                if (parent.getName() == null) {
                    parentNode = jcrNode;
                } else {
                    String ref = (String) parent.getReference();
                    parentNode = (javax.jcr.Node) jcrNode.getSession().getItem(ref);
                }

                // if the configuration node is an attribute, set the respective
                // property and return.
                if (ConfigurationKey.isAttributeKey(newNode.getName())) {
                    updateAttribute(parent, parentNode, newNode.getName());
                    return null;
                }

                // create the repository node for the configuration node
                javax.jcr.Node elem = parentNode.addNode(newNode.getName());

                // if the configuration node has a value, set the __DEFAULT__
                // property to this value
                if (newNode.getValue() != null) {
                    Value value =
                        exportValue(elem.getSession().getValueFactory(),
                            newNode.getValue());
                    elem.setProperty(NODE_CONTENT_PROPERTY, value);
                }

                // order before sibling2 if defined, ignore sibling1
                if (parentNode.getPrimaryNodeType().hasOrderableChildNodes()) {
                    if (sibling2 != null) {
                        parentNode.orderBefore(newNode.getName(),
                            sibling2.getName());
                    }
                }

                return elem.getPath();
            } catch (RepositoryException re) {
                log.warn("Cannot update repository for configuration node " +
                    newNode.getName(), re);
            }

            // fallback to returning nothing
            return null;
        }

        /**
         * Helper method for updating the value of the specified node's
         * attribute with the given name.
         *
         * @param node the affected node
         * @param elem the element that is associated with this node
         * @param name the name of the affected attribute
         */
        private void updateAttribute(Node node, javax.jcr.Node elem,
            String name) throws RepositoryException {
            if (node != null && elem != null) {
                String propName = ConfigurationKey.attributeName(name);

                // copy the values of all like named attributes to another list
                List attrs = node.getChildren(name);
                List values = new ArrayList();
                for (Iterator ai = attrs.iterator(); ai.hasNext();) {
                    Node attr = (Node) ai.next();
                    if (attr.getValue() != null) {
                        values.add(attr.getValue());
                    }
                }

                // remove property before trying to set
                if (elem.hasProperty(propName)) {
                    elem.getProperty(propName).remove();
                }

                Property attrProp;
                ValueFactory vf = elem.getSession().getValueFactory();
                if (values.size() == 0) {
                    // no attribute values
                    attrProp = null;
                } else if (values.size() == 1) {
                    // single valued property
                    attrProp =
                        elem.setProperty(propName, exportValue(vf, values.get(0)));
                } else {
                    Value[] valArray = new Value[values.size()];
                    for (int i = 0; i < valArray.length; i++) {
                        valArray[i] = exportValue(vf, values.get(i));
                    }
                    attrProp = elem.setProperty(propName, valArray);
                }

                // set the references on the attribute nodes
                String ref = attrProp != null ? attrProp.getPath() : null;
                for (Iterator ai = attrs.iterator(); ai.hasNext();) {
                    Node attr = (Node) ai.next();
                    attr.setReference(ref);
                }
            }
        }
    }

    //---------- Data type helpers for loading and storing --------------------

    /**
     * Converts the JCR <code>Value</code> object to a configuration value of
     * the corresponding runtime Java type. See the <a
     * href="#dataTypeConversion">class comment</a> for information on the type
     * conversion applied.
     *
     * @param jcrValue The JCR <code>Value</code> to convert into a
     *            configuration value object.
     * @return The configuration value object.
     * @throws NullPointerException if <code>jcrValue</code> is
     *             <code>null</code>.
     */
    private static Object importValue(Value jcrValue)
            throws RepositoryException {

        switch (jcrValue.getType()) {
            case PropertyType.BOOLEAN:
                return new Boolean(jcrValue.getBoolean());
            case PropertyType.DATE:
                return jcrValue.getDate();
            case PropertyType.DOUBLE:
                return new Double(jcrValue.getDouble());
            case PropertyType.LONG:
                return new Long(jcrValue.getLong());
            default:
                // Binary, Name, Path, Reference, String, Undefined
                return jcrValue.getString();
        }
    }

    /**
     * Converts the value object to a JCR <code>Value</code> instance
     * according to the runtime type of the <code>value</code>. See the <a
     * href="#dataTypeConversion">class comment</a> for information on the type
     * conversion applied.
     *
     * @param vf The <code>ValueFactory</code> used to create JCR
     *            <code>Value</code> objects.
     * @param value The configuration value to convert (export) to a JCR
     *            <code>Value</code> object.
     * @return The JCR <code>Value</code> object representing the
     *         configuration value.
     */
    private static Value exportValue(ValueFactory vf, Object value) {
        if (value instanceof String) {
            return vf.createValue((String) value);
        } else if (value instanceof Boolean) {
            return vf.createValue(((Boolean) value).booleanValue());
        } else if (value instanceof Calendar) {
            return vf.createValue((Calendar) value);
        } else if (value instanceof Double || value instanceof Float) {
            // handle float and double values as double
            return vf.createValue(((Number) value).doubleValue());
        } else if (value instanceof Number) {
            // handle other numbers (float and double above) as long
            return vf.createValue(((Number) value).longValue());
        } else {
            return vf.createValue(String.valueOf(value));
        }
    }
}